跳到主要内容

SpringSecurity 认证的编写流程

这篇笔记上接 《SpringSecurity 原理篇 认证流程》 这篇笔记,因为原文太长了,这里拆分开来,方便阅读,上文已经讲述了 SpringSecurity 的认证流程,这篇笔记就按照这个流程编写一个认证的模块

定义加密器Bean

@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

这个 Bean 是不必可少的,Spring Security 在认证操作时会使用我们定义的这个加密器,如果没有则会出现异常。

AuthenticationManager

@Bean
public AuthenticationManager authenticationManager() throws Exception {
return super.authenticationManager();
}

这里将 Spring Security 自带的 authenticationManager 声明成 Bean,声明它的作用是用它帮我们进行认证操作,调用这个 Bean 的 authenticate 方法会由 Spring Security 自动帮我们做认证。

UserInfo 用户信息表

创建一个实体来保存用户信息

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class UserInfo implements Serializable {

private static final long serialVersionUID = 1L;

@TableId(value = "id")
private String id;

private String username;

private String password;

private Integer activeStatus;

private LocalDateTime createTime;
}

RoleInfo 权限表

创建一个实体来保存权限表

@Data
@EqualsAndHashCode(callSuper = false)
@Accessors(chain = true)
public class RoleInfo implements Serializable {

private static final long serialVersionUID = 1L;

@TableId(value = "id")
private String id;

private String roleName;

private String roleCode;

private String roleRemark;

private Integer activeStatus;

private LocalDateTime createTime;
}

自定义 UserDetail

这个 UserDetail 用来保存用户信息

上面已经说过了 Authentication 中的 getAuthorities() 实际是由 UserDetails 的 getAuthorities() 传递而形成的。所以需要实现这个 getAuthorities 方法来填充权限

而这个 UserDetail 最终会被存入 Authentication 里面,可以通过 Authentication 的 getPrincipal() 方法取得这个存入的 UserDetail

@Data
public class UserDetail implements Serializable, UserDetails {
private static final long serialVersionUID = 1L;

// 这一块都是自定义的需要保存的数据,只需要能把 UserDetails 需要的数据返回出去,这里存的是啥都没关系
private UserInfo userInfo;
private List<RoleInfo> roleInfoList;
private Collection<? extends GrantedAuthority> grantedAuthorities;
private List<String> roles;

public String getUserId() {
return this.userInfo.getId();
}

// 返回当前用户信息的权限
@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
if (grantedAuthorities != null) return this.grantedAuthorities;

// 从 roleInfoList 中取出当前用户拥有的权限,填充进数组
List<SimpleGrantedAuthority> grantedAuthorities = new ArrayList<>();
// 使用 String 来保存权限
List<String> authorities = new ArrayList<>();
roleInfoList.forEach(role -> {
authorities.add(role.getRoleCode());
grantedAuthorities.add(new SimpleGrantedAuthority("ROLE_" + role.getRoleCode()));
});

this.grantedAuthorities = grantedAuthorities;
this.roles = authorities;
return this.grantedAuthorities;
}

@Override
public String getPassword() {
return this.userInfo.getPassword();
}

@Override
public String getUsername() {
return this.userInfo.getUsername();
}

/**
* 账户是否没过期
*
* @return boolean
*/
@Override
public boolean isAccountNonExpired() {
return true;
}

/**
* 账户是否没被锁定
*
* @return boolean
*/
@Override
public boolean isAccountNonLocked() {
return true;
}

/**
* 账户凭据是否没过期
*
* @return boolean
*/
@Override
public boolean isCredentialsNonExpired() {
return true;
}

/**
* 账户是否启用
*
* @return boolean
*/
@Override
public boolean isEnabled() {
return true;
}
}

实现 UserDetailsService

这个 UserDetailsService 是在 AuthenticationProvider(DaoAuthenticationProvider 这个实现类的代码看上面) 里面的 retrieveUser 方法里面调用

实现 UserDetailsService 的抽象方法并返回一个 UserDetails 对象,认证过程中 SpringSecurity 会调用这个方法访问数据库进行对用户的搜索(传入一个用户名),逻辑什么都可以自定义,无论是从数据库中还是从缓存中,但是我们需要将我们查询出来的用户信息和权限信息组装成一个 UserDetails 返回

说白了,查询用户是否存在就是这步

UserDetails 也是一个定义了数据形式的接口,用于保存我们从数据库中查出来的数据,其功能主要是验证账号状态和获取权限,具体看上面。

@Slf4j
@Service("userDetailsService")
public class CustomUserDetailsService implements UserDetailsService {
@Autowired
private UserService userService;
@Autowired
private RoleInfoService roleInfoService;

@Override
public UserDetails loadUserByUsername(String name) throws UsernameNotFoundException {
log.debug("开始登陆验证,用户名为: {}", name);

// 根据用户名验证用户
QueryWrapper<UserInfo> queryWrapper = new QueryWrapper<>();
queryWrapper.lambda().eq(UserInfo::getLoginAccount, name);

UserInfo userInfo = userService.getOne(queryWrapper);
if (userInfo == null) {
throw new UsernameNotFoundException("用户名不存在,登陆失败。");
}

// 构建UserDetail对象
UserDetail userDetail = new UserDetail();
userDetail.setUserInfo(userInfo);
List<RoleInfo> roleInfoList = roleInfoService.listRoleByUserId(userInfo.getUserId());
userDetail.setRoleInfoList(roleInfoList);
return userDetail;
}
}

编写 Controller

这里没什么好讲的,就是将登陆请求转发 authService,这个 api 地址会在下面访问控制那里注册进去

注:这里的 ApiResult 是自定义的标准返回对象,这里就不细述了

@RestController
@RequestMapping("/api/auth")
public class AuthController {

@Autowired
private AuthService authService;
@Autowired
private JwtProvider jwtProvider;

@PostMapping("/login")
public ApiResult login(@Valid @RequestBody LoginInfo loginInfo) {
// 这里的 loginAccount 就是 username
return authService.login(loginInfo.getLoginAccount(), loginInfo.getPassword());
}

@PostMapping("/logout")
public ApiResult logout() {
return authService.logout();
}

@PostMapping("/refresh")
public ApiResult refreshToken(HttpServletRequest request) {
return authService.refreshToken(jwtProvider.getToken(request));
}
}

编写认证服务 ⭐

先创建一个 AuthService 接口

public interface AuthService {
// 这里的 loginAccount 就是 username
ApiResult login(String loginAccount, String password);

ApiResult logout();

ApiResult refreshToken(String token);
}

编写实现类,这里的 Cache 就是封装了一下 CacheManager,细节直接看 原作者的源码,实际上使用 Redis 当缓存也是可以这么玩的,这里学习一下

这里具体的 Jwt 细节就不写了,都大同小异,关键在于下面的 login 执行流程

@Slf4j
@Service
public class AuthServiceImpl implements AuthService {
@Autowired
private AuthenticationManager authenticationManager;
@Autowired
private JwtProvider jwtProvider;
@Autowired
private Cache caffeineCache;


@Override
public ApiResult login(String loginAccount, String password) {

log.debug("进入login方法");
// 1 创建 UsernamePasswordAuthenticationToken 这里的 loginAccount 就是 username
UsernamePasswordAuthenticationToken usernameAuthentication = new UsernamePasswordAuthenticationToken(loginAccount, password);
// 2 认证
Authentication authentication = this.authenticationManager.authenticate(usernameAuthentication);
// 3 保存认证信息
SecurityContextHolder.getContext().setAuthentication(authentication);
// 4 生成自定义token
AccessToken accessToken = jwtProvider.createToken((UserDetails) authentication.getPrincipal());
UserDetail userDetail = (UserDetail) authentication.getPrincipal();

// 5 放入缓存
caffeineCache.put(CacheName.USER, userDetail.getUsername(), userDetail);
return ApiResult.ok(accessToken);
}

@Override
public ApiResult logout() {
caffeineCache.remove(CacheName.USER, AuthProvider.getLoginAccount());
SecurityContextHolder.clearContext();
return ApiResult.ok();
}

@Override
public ApiResult refreshToken(String token) {
AccessToken accessToken = jwtProvider.refreshToken(token);
UserDetail userDetail = caffeineCache.get(CacheName.USER, accessToken.getLoginAccount(), UserDetail.class);
caffeineCache.put(CacheName.USER, accessToken.getLoginAccount(), userDetail);
return ApiResult.ok(accessToken);
}
}

具体认证步骤

1、传入用户名和密码创建了一个 UsernamePasswordAuthenticationToken 对象,这是前面说过的 Authentication 的实现类,传入用户名和密码做构造参数,这个对象就是我们创建出来的未认证的 Authentication 对象。

2、使用先前已经声明过的 Bean-authenticationManager 调用它的 authenticate 方法进行认证,返回一个认证完成的 Authentication 对象。

3、认证完成没有出现异常,就会走到第三步,使用 SecurityContextHolder 获取 SecurityContext 之后,将认证完成之后的 Authentication 对象,放入上下文对象。

4、从 Authentication 对象中拿到我们的 UserDetails 对象,之前我们说过,认证后的 Authentication 对象调用它的 getPrincipal() 方法就可以拿到我们先前数据库查询后组装出来的 UserDetails 对象,然后创建 token。

5、把 UserDetails 对象放入缓存中,方便后面过滤器使用。

这样的话就算完成了,感觉上很简单,因为主要认证操作都会由 authenticationManager.authenticate() 帮我们完成。

authenticate 方法

这个 authenticate 方法就是认证的核心方法,它位于 AbstractUserDetailsAuthenticationProvider 这个抽象类里面,而大部分具体的 AuthenticationProvider 都是先继承自这个抽象类,具体看上面的类图

接下来我们可以看看源码,从中窥得 Spring Security 是如何帮我们做这个认证的(省略了一部分):

// AbstractUserDetailsAuthenticationProvider
// 注意这个 AbstractUserDetailsAuthenticationProvider 类是上面的 DaoAuthenticationProvider 的父类,
// 认证部分都是在这个父类做的
public Authentication authenticate(Authentication authentication) {

// 校验未认证的Authentication对象里面有没有用户名
String username = (authentication.getPrincipal() == null) ? "NONE_PROVIDED"
: authentication.getName();

boolean cacheWasUsed = true;
// 从缓存中去查用户名为XXX的对象
UserDetails user = this.userCache.getUserFromCache(username);
// 如果没有就进入到这个方法
if (user == null) {
cacheWasUsed = false;
try {
// 调用我们重写 UserDetailsService 的 loadUserByUsername 方法
// 拿到我们自己组装好的 UserDetails 对象
user = retrieveUser(username,
(UsernamePasswordAuthenticationToken) authentication);

} catch (UsernameNotFoundException notFound) {
logger.debug("User '" + username + "' not found");
if (hideUserNotFoundExceptions) {
throw new BadCredentialsException(messages.getMessage(
"AbstractUserDetailsAuthenticationProvider.badCredentials",
"Bad credentials"));
} else {
throw notFound;
}
}
Assert.notNull(user,
"retrieveUser returned null - a violation of the interface contract");
}
try {
// 校验账号是否禁用
preAuthenticationChecks.check(user);
// 校验数据库查出来的密码,和我们传入的密码是否一致
additionalAuthenticationChecks(user,
(UsernamePasswordAuthenticationToken) authentication);
}
}

添加 JWT 过滤器

有了 token 之后,我们要把过滤器放在过滤器链中,用于解析 token,因为我们没有 session,所以我们每次去辨别这是哪个用户的请求的时候,都是根据请求中的 token 来解析出来当前是哪个用户。

所以我们需要一个过滤器去拦截所有请求,前文我们也说过,这个过滤器我们会放在绿色部分用来替代 UsernamePasswordAuthenticationFilter,所以我们新建一个 JwtAuthenticationTokenFilter,然后将它注册为 Bean,并在编写配置文件的时候需要加上这个:

@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() {
return new JwtAuthenticationTokenFilter();
}

@Override
protected void configure(HttpSecurity http) throws Exception {
// 将自定义的 JWT 过滤器放到过滤链中
http.addFilterBefore(jwtAuthenticationTokenFilter(),
UsernamePasswordAuthenticationFilter.class);
}

addFilterBefore 的语义是添加一个 Filter 到 XXXFilter 之前,放在这里就是把 JwtAuthenticationTokenFilter 放在 UsernamePasswordAuthenticationFilter 之前,因为 filter 的执行也是有顺序的,我们必须要把我们的 filter 放在过滤器链中绿色的部分才会起到自动认证的效果。

具体实现代码就不写了,可以直接看 作者的源码

配置访问控制

最后把上面的都配置进来

@EnableWebSecurity
public class SecurityConfig extends WebSecurityConfigurerAdapter {
@Bean
public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder();
}

// 注册自定义的 JWT 过滤器
@Bean
public JwtAuthenticationTokenFilter jwtAuthenticationTokenFilter() {
return new JwtAuthenticationTokenFilter();
}

// 注册上面自定义实现的 UserDetailsService
@Bean
public CustomUserDetailsService customUserDetailsService() {
return new CustomUserDetailsService();
}

// 注意,如果直接调用了 AuthenticationManager 来做认证,需要将其注册进去
@Override
@Bean
public AuthenticationManager authenticationManagerBean() throws Exception {
return super.authenticationManagerBean();
}

// 注意这里配置了默认的 AuthenticationManager
@Override
protected void configure(AuthenticationManagerBuilder auth) throws Exception {
auth.userDetailsService(customUserDetailsService()).passwordEncoder(passwordEncoder());
}

@Override
protected void configure(HttpSecurity http) throws Exception {

http.authorizeRequests()
// 放行所有OPTIONS请求
.antMatchers(HttpMethod.OPTIONS).permitAll()
// 放行登录方法
.antMatchers("/api/auth/login").permitAll()
// 认证只需上面的 /api/auth/login 就行了,下面的属于鉴权部分,看下一篇笔记
.and()
// 将自定义的JWT过滤器放到过滤链中
.addFilterBefore(jwtAuthenticationTokenFilter(), UsernamePasswordAuthenticationFilter.class)
// 打开Spring Security的跨域
.cors()
.and()
// 关闭CSRF
.csrf().disable()
// 关闭Session机制
.sessionManagement().sessionCreationPolicy(SessionCreationPolicy.STATELESS);
}
}